Skip to content

Show read receipts #1706

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open

Show read receipts #1706

wants to merge 8 commits into from

Conversation

sm-sayedi
Copy link
Collaborator

@sm-sayedi sm-sayedi commented Jul 15, 2025

Support for viewing read receipts of a message.

Figma link: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=11367-20647&t=bpmAb3Fr9aJdMIHw-0

Screenshots

Message action sheet Read receipts - Many
Read receipts - One Read receipts - Zero Read receipts - Failed

Screen recording

Read.receipts.-.Screen.recording.mov

Fixes: #667

@sm-sayedi sm-sayedi marked this pull request as draft July 15, 2025 11:23
@alya
Copy link
Collaborator

alya commented Jul 15, 2025

We should probably use the same icon as in the web app here:

Screenshot 2025-07-15 at 09 59 19@2x

color: designVariables.title,
).merge(weightVariableTextStyle(context, wght: 600))),
if (status == FetchStatus.success && receiptCount > 0)
StyledText(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very interesting, I didn't expect to be able to do this! Is this a solution to #1285?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at the issue, I think this could be a solution for it!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm interesting.

This wouldn't be a satisfactory solution to all of #1285, because this appears to fire up a whole XML parser for each one of these widgets, which is going to be expensive. That's acceptable in this context, where there's only one of these at a time (and it isn't a very common part of the UI anyway). It wouldn't be OK for something that could appear in, say, message content, where you might scroll past a lot of them.

Copy link
Member

@gnprice gnprice Aug 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've written up a small TextWithLink widget which does the thing we need here more directly. Pushing that to this branch as a pair of additional commits on top:
c33ee61 text: Introduce TextWithLink widget
93e8919 (squash) Use our TextWithLink instead of package:styled_text

I think for the other cases we need for #1285, I'll be happier with further developing that widget than with what package:styled_text is doing. In particular the thing that I feel least confident about in what's missing from TextWithLink is this:

/// TODO(#1285): Integrate this with l10n so that the markup can be parsed
///   from the constant translated string, with placeholders for any variables,
///   rather than the string that results from interpolating variables.
///   That way it'll be fine to interpolate variables with arbitrary text.

but package:styled_text doesn't handle that either.

@sm-sayedi sm-sayedi force-pushed the 667-read-receipts branch from 7c6c52c to d05ac9d Compare July 16, 2025 18:19
Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Here's comments from an initial skim.

}
}

class ViewReadReceiptsButton extends MessageActionSheetMenuItemButton {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An intermediate commit has:

  @override void onPressed() {
    // TODO: open read receipts sheet
  }

So it shows the button but the button doesn't do anything. That's a buggy state, so let's avoid it.

That commit is quite small, so we can just squash it into the later commit that gives this button something to do.

Comment on lines 102 to 108
/// A plain text widget for a bottom sheet with a multiline UI string.
///
/// Comes with built-in 16px horizontal padding.
///
/// Figma:
/// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3481-26993&m=dev
class BottomSheetPlainText extends StatelessWidget {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This name is very general. It's hard for me to tell what sort of situations this is meant for, or what it does.

If I want plain text, that's what Text does, right? It's not clear how the bottom sheet interacts with that.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about the new name BottomSheetInfoText?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That does suggest more information. Can you explain a bit more in the widget's doc?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated its doc.

Comment on lines 149 to 152
child: status == FetchStatus.loading
? CircularProgressIndicator()
: status == FetchStatus.error
? BottomSheetPlainText(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This chain of ?: would be best as a switch (status). That way it's clear we handle all the possible values.

final localizations = ZulipLocalizations.of(context);
final designVariables = DesignVariables.of(context);

return Center(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this Center end up doing anything in the three cases other than CircularProgressIndicator? I suspect it doesn't.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It also centers the plain text in the sheet, especially vertically.🙂

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the current revision, I believe this Center only has an effect in the FetchStatus.loading state, and otherwise it does nothing, which is confusing. (BottomSheetEmptyContentPlaceholder takes care of positioning the "no-read-receipts" message in the error and success cases.)

I think it would be reasonable to give that new widget the job of showing a loading indicator as appropriate, and then this Center can go away. Could give it a loading param, requiring either that or message but not both`, and have it show the loading spinner in the place where message would go; something like:

--- lib/widgets/action_sheet.dart
+++ lib/widgets/action_sheet.dart
@@ -182,7 +182,11 @@ class BottomSheetHeader extends StatelessWidget {
   }
 }
 
-/// A "no content here" message for when a bottom sheet has no content to show.
+/// A placeholder for when a bottom sheet has no content to show.
+///
+/// Pass [message] for a "no-content-here" message,
+/// or pass true for [loading] if the content hasn't finished loading yet,
+/// but don't pass both.
 ///
 /// Show this below a [BottomSheetHeader] if present.
 ///
@@ -192,26 +196,35 @@ class BottomSheetHeader extends StatelessWidget {
 // TODO(design) we don't yet have a design for this;
 //   it was ad-hoc and modeled on [PageBodyEmptyContentPlaceholder].
 class BottomSheetEmptyContentPlaceholder extends StatelessWidget {
-  const BottomSheetEmptyContentPlaceholder({super.key, required this.message});
+  const BottomSheetEmptyContentPlaceholder({
+    super.key,
+    this.message,
+    this.loading = false,
+  }) : assert(message == null || !loading);
 
-  final String message;
+  final String? message;
+  final bool loading;
 
   @override
   Widget build(BuildContext context) {
     final designVariables = DesignVariables.of(context);
 
-    return Padding(
-      padding: EdgeInsets.fromLTRB(24, 48, 24, 16),
-      child: Align(
-        alignment: Alignment.topCenter,
-        child: Text(
+    final child = loading
+      ? CircularProgressIndicator()
+      : Text(
           textAlign: TextAlign.center,
           style: TextStyle(
             color: designVariables.labelSearchPrompt,
             fontSize: 17,
             height: 23 / 17,
           ).merge(weightVariableTextStyle(context, wght: 500)),
-          message)));
+          message!);
+
+    return Padding(
+      padding: EdgeInsets.fromLTRB(24, 48, 24, 16),
+      child: Align(
+        alignment: Alignment.topCenter,
+        child: child));
   }
 }

// TODO: deduplicate the code with [ViewReactionsUserItem]
@visibleForTesting
class ReadReceiptsUserItem extends StatelessWidget {
const ReadReceiptsUserItem(this.pageContext, {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: widget parameters should always be named (with very rare exceptions like Text)

padding: EdgeInsets.symmetric(vertical: 8),
itemCount: userIds.length,
itemBuilder: (context, index) =>
ReadReceiptsUserItem(context, userId: userIds[index]))));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The child widget here calls this argument pageContext, but the context we're passing looks like just its own parent's context. If this context works, then I expect the child's own context would work equally well.

(This is also an example of why it's good for widget parameters to be named — if this said pageContext: context, then the contrast would jump out more.)

Comment on lines 225 to 230
/// https://zulip.com/api/get-read-receipts
Future<GetReadReceiptsResult> getReadReceipts(ApiConnection connection, {
required int messageId,
}) {
return connection.get('getReadReceipts', GetReadReceiptsResult.fromJson,
'messages/$messageId/read_receipts', null);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should go in lib/api/ :slightly_smiling_face:

color: designVariables.title,
).merge(weightVariableTextStyle(context, wght: 600))),
if (status == FetchStatus.success && receiptCount > 0)
StyledText(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm interesting.

This wouldn't be a satisfactory solution to all of #1285, because this appears to fire up a whole XML parser for each one of these widgets, which is going to be expensive. That's acceptable in this context, where there's only one of these at a time (and it isn't a very common part of the UI anyway). It wouldn't be OK for something that could appear in, say, message content, where you might scroll past a lot of them.

@sm-sayedi sm-sayedi force-pushed the 667-read-receipts branch 2 times, most recently from 2ed033d to 2b769fd Compare July 30, 2025 18:45
@sm-sayedi sm-sayedi marked this pull request as ready for review July 30, 2025 18:46
@sm-sayedi sm-sayedi requested a review from chrisbobbe July 30, 2025 18:49
@sm-sayedi sm-sayedi added the maintainer review PR ready for review by Zulip maintainers label Jul 30, 2025
@sm-sayedi
Copy link
Collaborator Author

Thanks @gnprice for the previous review. New changes pushed. Marked for Chris's review.

@sm-sayedi sm-sayedi force-pushed the 667-read-receipts branch from 2b769fd to 24a4e8e Compare July 31, 2025 11:39
@sm-sayedi
Copy link
Collaborator Author

Pushed a new revision with tests included.

@sm-sayedi sm-sayedi force-pushed the 667-read-receipts branch from 24a4e8e to 961fcd8 Compare July 31, 2025 16:17
Copy link
Collaborator

@chrisbobbe chrisbobbe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this is exciting! Comments below, from reading all the commits except the last, which I've read only partially so far.

@@ -48,143 +48,146 @@ abstract final class ZulipIcons {
/// The Zulip custom icon "check".
static const IconData check = IconData(0xf108, fontFamily: "Zulip Icons");

/// The Zulip custom icon "check_check".
static const IconData check_check = IconData(0xf109, fontFamily: "Zulip Icons");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

icons: Add check_check icon, from Web Figma file

I started a discussion on this; I don't think there's a change to make right now, but please link to it in the commit message for context: #mobile-design > read-receipts icon in #F1706 @ 💬

Comment on lines 101 to 120
/// A header for a bottom sheet with a multiline UI string.
/// A plain text widget for a bottom sheet with a multiline UI string.
///
/// Assumes 8px padding below the top of the bottom sheet.
/// Use it to present short, non-interactive explanatory messages to the user,
/// such as an error message or other feedback.
///
/// Comes with built-in 16px horizontal padding.
///
/// Figma:
/// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3481-26993&m=dev
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

action_sheet: Rename BottomSheetHeaderPlainText to BottomSheetInfoText

This commit decreases the number of widgets that

  • declare the job of following one specific source in the Figma,
  • do that job correctly, and
  • have an implementation that's easy to compare to that source. 🙂

Let's make a new widget for the new not-header thing, and keep this widget for a header. This would be an example of the principle "prefer duplication over the wrong abstraction", I think 🙂, which Greg taught me about a while ago: https://chat.zulip.org/#narrow/sender/2187-Greg-Price/search/wrong.20abstraction

aside

I could more easily imagine BottomSheetHeaderPlainText growing a "title/body" variant, if that's helpful for following this part of the Figma—

image

; it would still have the "header" abstraction, which seems right. In fact, that might be helpful—the Figma's 18px horizontal padding kind of looks like a mistake to me; the plain-text version has 16px. A shared widget would be a natural place for a code comment that mentions both kinds of headers. We'd want to make sure the widget still has a clear, coherent interface, with a link to the Figma source for the new behavior.

Copy link
Collaborator Author

@sm-sayedi sm-sayedi Aug 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could more easily imagine BottomSheetHeaderPlainText growing a "title/body" variant,...

Should we create a new widget for the header in this PR, with a name like BottomSheetHeader? One distinction of this from BottomSheetHeaderPlainText is that the latter has a different styling than the title/body of this header widget.

(Thanks for the link to that interesting blog post! 🙂)

"@actionSheetReadReceipts": {
"description": "Title for the \"Read receipts\" bottom sheet."
},
"actionSheetReadReceiptsReadCount": "This message has been <link href=\"https://chat.zulip.org/help/read-receipts\">read</link> by {count} {count, plural, =1{person} other{people}}:",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't give translators control of the URL. If the Help Center page moves, we should only have to make a one-line change in our source code, instead of going through and changing language data for all the languages. 🙂

See web's string, can we do something like <z-link> there?

{num_of_people, plural, one {This message has been <z-link>read</z-link> by {num_of_people} person:} other {This message has been <z-link>read</z-link> by {num_of_people} people:}}

Also I think we should link to https://zulip.com/help/read-receipts instead of that page on CZO.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also IIUC that plural syntax works, but I think we'd normally do

{count, plural, =1{1 person} other{{count} people}}

in case that's a more helpful model when translating into a language that varies the word order among the one/two/few/many/other cases? (I'm not actually sure if such languages exist actually.)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, in fact best to have the whole sentence inside the plural block — like it is in that example from web.

That way if e.g. the form of the verb needs to change too, the translator has that flexibility.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That way if e.g. the form of the verb needs to change too, the translator has that flexibility.

nit: IIUC the translator has that flexibility already; they can choose to write the translated string however they want, including by re-scoping the plural block (or even omitting it which would probably be wrong). But by putting the whole sentence in the plural block in English, we're potentially helping by minimizing the things a translator has to think through in order to make a correct translation for their language.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True. So it's more that we're giving them a model that makes it easier to see how to apply that flexibility.

@@ -720,6 +721,7 @@ void showMessageActionSheet({required BuildContext context, required Message mes
ReactionButtons(message: message, pageContext: pageContext),
if (hasReactions)
ViewReactionsButton(message: message, pageContext: pageContext),
ViewReadReceiptsButton(message: message, pageContext: pageContext),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should store and check the org's "Enable read receipts" setting before including the button; see realm_enable_read_receipts in https://zulip.com/api/register-queue

Comment on lines 191 to 194
overlayColor: WidgetStateColor.resolveWith((states) =>
states.any((e) => e == WidgetState.pressed)
? designVariables.contextMenuItemBg.withFadedAlpha(0.20)
: Colors.transparent),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about

      overlayColor: WidgetStateColor.fromMap({
        WidgetState.pressed: designVariables.contextMenuItemBg.withFadedAlpha(0.20)
      }),

?

I updated my #1700 with that in response to #1700 (comment) 🙂 (there might be other things that would be helpful to bring over from ViewReactionsUserItem in my current revision there)

Copy link
Collaborator

@chrisbobbe chrisbobbe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And here's a full review of that last commit. Generally looks great; small comments below.

@override
Widget build(BuildContext context) {
return SizedBox(
height: 500,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the view-reactions sheet, I used 400 with a TODO(design) tune. I'd like to be consistent between the two places unless there's a reason not to, but I don't mind which value is used; 500 is fine if you think it's better.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah. ThisSizedBox is a little bit different from the SizedBox in view-reactions sheet. Here, the height of the view-receipts sheet is 500, and in view-reactions sheet, the height of the user-item list is 400 (excluding the reactions-header and dismiss-button). In fact, the height of that sheet is 518px (I can make this sheet's size 518 to make it consistent). I wrapped SizedBox on the entire sheet (instead of the user-item list) so that when there are no read receipts, the sheet's height is not changed.

Maybe the same approach could be applied to view-reactions sheet to address the following comment:

// TODO if all reactions (or whole message) disappeared,
// we show a message saying there are no reactions,
// but the layout shifts (the sheet's height changes dramatically);
// we should avoid this.
if (reactionType != null && emojiCode != null) Flexible(
child: ViewReactionsUserList(
messageId: widget.messageId,
reactionType: reactionType!,
emojiCode: emojiCode!,
emojiName: emojiName!)),

Comment on lines 51 to 57
@override
void didChangeDependencies() {
super.didChangeDependencies();

tryFetchReadReceipts();
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This means we can end up fetching multiple times, so I think my ideal version would have generation logic like in lib/model/message_list.dart, to invalidate stale fetches. If results arrive out of order, we don't want to get stuck showing old results when we know we have results from a fetch we made more recently.

I think that can be a followup though—I can write up a more detailed proposal later, or perhaps just send a draft to demonstrate, once this work is merged.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are the reasons we would want to do a new fetch because a dependency changed?

I think it may only be if there's a new store. So this could go in an onNewStore method instead.

Then the generation-like logic can just be: when getting the response, check if the store we had at the start of the request is still the current store (with identical). If not, ignore the response.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, that's reasonable. In zulip-mobile I implemented polling to refresh this page every 15s (IIRC), and generic generation logic would help if we did that as a followup.

But arguably the polling wasn't really needed in zulip-mobile, and it's maybe less compelling in zulip-flutter, where the view is more clearly ephemeral, not something you'd necessarily expect to stay up-to-date if you left it foregrounded in the app. (A modal bottom sheet vs. a whole full-screen page with a back button.)

});
}

class ReadReceipts extends StatefulWidget {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's link to the Figma, so it's easier for readers to check the implementation against the spec.

final designVariables = DesignVariables.of(context);

return Padding(
padding: EdgeInsetsDirectional.fromSTEB(18, 16, 18, 8),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a rare case where I disagree with the Figma (link). Can we change the horizontal padding to 16 and the bottom padding to 4, like in BottomSheetHeaderPlainText (which in main follows this in the Figma)?

Here's a before/after of my proposal (from the current revision):

Before After
image image

You can leave a code comment explaining that we prefer consistency with that other header, with a link to this GitHub comment.


@override
Widget build(BuildContext context) {
final localizations = ZulipLocalizations.of(context);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: we generally write zulipLocalizations instead of localizations; that way it's distinct from other localizations like MaterialLocalizations.

Comment on lines 123 to 126
style: TextStyle(
decoration: TextDecoration.underline,
color: designVariables.link,
decorationColor: designVariables.link),
Copy link
Collaborator

@chrisbobbe chrisbobbe Aug 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Figma specifies some other details about the underline style. Let's follow any of those that we can (e.g. decorationThickness, I think) and write TODO(upstream)s for the others.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there's an important difference between the two thicknesses in Figma and Flutter, and I am not sure how we can map between them. In Figma, it's a percentage of the font size of the text to which the decoration belongs. In Flutter, it is a multiplier of the thickness defined by the font.

Copy link
Collaborator

@chrisbobbe chrisbobbe Aug 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Odd, thanks for noticing that.

From Flutter's decorationThickness doc, it does seem like you can provide a constant (like 1.5 or 2.0 or whatever) and the thickness you actually get will change with the font size. That seems desirable.

But if it's true that font files always include a preferred underline stroke-width value (as that doc says), I haven't been able to find that value for the font we're using here, Source Sans 3. 🤷‍♂️

@@ -171,6 +171,8 @@ class DesignVariables extends ThemeExtension<DesignVariables> {
labelEdited: const HSLColor.fromAHSL(0.35, 0, 0, 0).toColor(),
labelMenuButton: const Color(0xff222222),
labelSearchPrompt: const Color(0xff000000).withValues(alpha: 0.5),
labelTime: const Color(0x00000000).withValues(alpha: 0.49),
link: const Color(0xff066bd0),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I didn't find this in the list of Figma variables that I usually look at.

Turns out it's defined in the "Zulip Web UI kit": https://www.figma.com/design/msWyAJ8cnMHgOMPxi7BUvA/Zulip-Web-UI-kit?node-id=0-1&p=f&vars=1&var-id=b9c211a7c212568bc84139413454dd8dd07321fb%2F833-56&m=dev

image

It has distinct light/dark values; we should use both. And maybe add a comment like

    link: const Color(0xff066bd0), // From "Zulip Web UI kit"

Comment on lines 75 to 76
Finder findUserItem(String fullName) => find.ancestor(of: find.text(fullName),
matching: find.byType(ReadReceiptsUserItem));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about:

Suggested change
Finder findUserItem(String fullName) => find.ancestor(of: find.text(fullName),
matching: find.byType(ReadReceiptsUserItem));
Finder findUserItem(String fullName) =>
find.widgetWithText(ReadReceiptsUserItem, fullName);

?

Comment on lines 86 to 87
delay: transitionDurationObserver.transitionDuration
+ const Duration(milliseconds: 100));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It takes some nonlocal reasoning to work out which nav-transition this is about, and I notice that we're always passing the same duration here.

How about replacing prepareReceiptsResponse with prepareReceiptsResponseSuccess(List<int> userIds) and prepareReceiptsResponseError(Object? httpException), and control the delay in setupReceiptsSheet?

Comment on lines 157 to 160
check(find.byWidgetPredicate((widget) => switch(widget) {
ProfilePage(userId: 1) => true,
_ => false,
})).findsOne();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can be two lines instead of four I think 🙂

      check(find.byWidgetPredicate((widget) => widget is ProfilePage && widget.userId == 1))
        .findsOne();

final result = await getReadReceipts(store.connection, messageId: widget.messageId);
// TODO(i18n): add locale-aware sorting
userIds = result.userIds.sortedByCompare(
(id) => store.userDisplayName(id),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Relatedly: in general we never want to keep a reference to the store across an await and use it again after, because the old store might already have been disposed. Instead, after an await we should look up the store afresh using a context.

@sm-sayedi sm-sayedi force-pushed the 667-read-receipts branch 2 times, most recently from 4046f17 to 6ae29f9 Compare August 2, 2025 21:55
@sm-sayedi
Copy link
Collaborator Author

Thanks @chrisbobbe for the review! New revision pushed, PTAL.

@gnprice
Copy link
Member

gnprice commented Aug 4, 2025

Posted a comment a few minutes ago on a subthread above which should also be made visible here in chronological order on the PR thread. So here's a link #1706 (comment) and a copy:

I've written up a small TextWithLink widget which does the thing we need here more directly. Pushing that to this branch as a pair of additional commits on top:
c33ee61 text: Introduce TextWithLink widget
93e8919 (squash) Use our TextWithLink instead of package:styled_text

I think for the other cases we need for #1285, I'll be happier with further developing that widget than with what package:styled_text is doing. In particular the thing that I feel least confident about in what's missing from TextWithLink is this:

/// TODO(#1285): Integrate this with l10n so that the markup can be parsed
///   from the constant translated string, with placeholders for any variables,
///   rather than the string that results from interpolating variables.
///   That way it'll be fine to interpolate variables with arbitrary text.

but package:styled_text doesn't handle that either.

@sm-sayedi
Copy link
Collaborator Author

Thanks @gnprice for the new code. Changes pushed.

@sm-sayedi sm-sayedi force-pushed the 667-read-receipts branch 2 times, most recently from bc2a507 to 9cfa559 Compare August 5, 2025 07:34
Copy link
Collaborator

@chrisbobbe chrisbobbe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Comments below, and I've pushed four new commits:

f92e7f9 action_sheet [nfc]: Make BottomSheetHeaderPlainText more flexible; rename
9ee2e3c action_sheet: Implement BottomSheetEmptyContentPlaceholder, to use soon
bad5747 read_receipts [nfc]: Use BottomSheetHeader for read-receipts header
4eacb89 read_receipts: Use BottomSheetEmptyContentPlaceholder for empty-content

These are suggestions; if they make sense (see my comments), please rearrange the branch

  • to put the first two of those before the main commit that implements the feature, and
  • squash the last two commits into that main commit.

Excited to have this soon! 🎉

Comment on lines 507 to 520
span = TextSpan(text: match.group(1), children: [
TextSpan(text: match.group(2), recognizer: _recognizer,
style: TextStyle(
decoration: TextDecoration.underline,
decorationStyle: TextDecorationStyle.solid,
// We use the default value for this, as there's no obvious
// way to map the thickness value from Figma design as it is
// a percentage of the font size.
decorationThickness: 1,
// decorationOffset: // TODO(upstream): https://github.com/flutter/flutter/issues/30541
color: designVariables.link,
decorationColor: designVariables.link)),
TextSpan(text: match.group(3)),
]);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This parent/child structure feels odd; can we make the three parts be siblings instead?

      span = TextSpan(children: [
        TextSpan(text: match.group(1)),
        TextSpan(text: match.group(2), /* etc. */),
        TextSpan(text: match.group(3)),
      ]);

Comment on lines 131 to 141
/// A plain text widget for informational content in a bottom sheet.
///
/// Use it to present short, non-interactive explanatory messages to the user,
/// such as an error message or other feedback.
///
/// Comes with built-in 16px horizontal padding.
///
/// Style-wise, this mostly follows the design of [BottomSheetHeaderPlainText],
/// as there is no design for this in Figma right now.
// TODO(design) create
class BottomSheetInfoText extends StatelessWidget {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking more about this, I think my preference is:

  • Declare an API that's explicitly about "empty content". One sign of a nice API is that, when you read about it, it's clear early on whether it's something you want to use or not. This BottomSheetInfoText widget catches the attention of a would-be caller who wants to show some "info text", but then it's not clear whether this or BottomSheetHeaderPlainText is the better way to do that—especially because this says the style is modeled on that header widget :)
  • Instead of BottomSheetInfoText, I suggest basically duplicating PageBodyEmptyContentPlaceholder, adapted for use in a bottom sheet, and with bidirectional links. Then:
    • "Empty content placeholder" answers the "should-I-use-this" question more quickly than "info text".
    • It actually sort of comes from a Figma frame, though indirectly 🙂

For example:

/// A "no content here" message for when a bottom sheet has no content to show.
///
/// Show this below a [BottomSheetHeader] if present.
///
/// See also:
///  * [PageBodyEmptyContentPlaceholder], for a similar element to use in
///    pages on the home screen.
// TODO(design) we don't yet have a design for this;
//   it was ad-hoc and modeled on [PageBodyEmptyContentPlaceholder].
class BottomSheetEmptyContentPlaceholder extends StatelessWidget {
  const BottomSheetEmptyContentPlaceholder({super.key, required this.message});

  final String message;

  @override
  Widget build(BuildContext context) {
    final designVariables = DesignVariables.of(context);

    return Padding(
      padding: EdgeInsets.fromLTRB(24, 48, 24, 16),
      child: Align(
        alignment: Alignment.topCenter,
        child: Text(
          textAlign: TextAlign.center,
          style: TextStyle(
            color: designVariables.labelSearchPrompt,
            fontSize: 17,
            height: 23 / 17,
          ).merge(weightVariableTextStyle(context, wght: 500)),
          message)));
  }
}

Here's how it would look:

Before After
image image
image image

I'll push a draft commit with that example.

@sm-sayedi sm-sayedi force-pushed the 667-read-receipts branch 2 times, most recently from c2b8047 to 386882f Compare August 7, 2025 06:43
@sm-sayedi
Copy link
Collaborator Author

Thanks @chrisbobbe for the review and the code. New revision pushed.

Also, it would be great to have your thoughts on #1706 (comment) about making the height of view-reactions and view-read-receipts action sheets the same. 🙂

@sm-sayedi sm-sayedi requested a review from chrisbobbe August 7, 2025 06:51
Copy link
Collaborator

@chrisbobbe chrisbobbe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yeah, thanks for the ping on the height question :). I think the height and small inconsistency is fine to merge; I don't want to block this on further changes about the height, and I'm working on something locally that I might send as a followup.

Small comments below.

Comment on lines 119 to 120
final url = PerAccountStoreWidget.of(context).tryResolveUrl(ReadReceipts._helpCenterReference)
?? Uri.parse(ReadReceipts._helpCenterUrl);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can resolving the URL ever actually fail? I don't think it can; we control the path part ("/help/read-receipts"), and we have the realm URL as a Uri object, which means it passed some level of validation. I think we can do tryResolveUrl(…)! (throwing an error if it fails), instead of opening a page on zulip.com that might have wrong information because it was written to correspond with a different Zulip server version.

final localizations = ZulipLocalizations.of(context);
final designVariables = DesignVariables.of(context);

return Center(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the current revision, I believe this Center only has an effect in the FetchStatus.loading state, and otherwise it does nothing, which is confusing. (BottomSheetEmptyContentPlaceholder takes care of positioning the "no-read-receipts" message in the error and success cases.)

I think it would be reasonable to give that new widget the job of showing a loading indicator as appropriate, and then this Center can go away. Could give it a loading param, requiring either that or message but not both`, and have it show the loading spinner in the place where message would go; something like:

--- lib/widgets/action_sheet.dart
+++ lib/widgets/action_sheet.dart
@@ -182,7 +182,11 @@ class BottomSheetHeader extends StatelessWidget {
   }
 }
 
-/// A "no content here" message for when a bottom sheet has no content to show.
+/// A placeholder for when a bottom sheet has no content to show.
+///
+/// Pass [message] for a "no-content-here" message,
+/// or pass true for [loading] if the content hasn't finished loading yet,
+/// but don't pass both.
 ///
 /// Show this below a [BottomSheetHeader] if present.
 ///
@@ -192,26 +196,35 @@ class BottomSheetHeader extends StatelessWidget {
 // TODO(design) we don't yet have a design for this;
 //   it was ad-hoc and modeled on [PageBodyEmptyContentPlaceholder].
 class BottomSheetEmptyContentPlaceholder extends StatelessWidget {
-  const BottomSheetEmptyContentPlaceholder({super.key, required this.message});
+  const BottomSheetEmptyContentPlaceholder({
+    super.key,
+    this.message,
+    this.loading = false,
+  }) : assert(message == null || !loading);
 
-  final String message;
+  final String? message;
+  final bool loading;
 
   @override
   Widget build(BuildContext context) {
     final designVariables = DesignVariables.of(context);
 
-    return Padding(
-      padding: EdgeInsets.fromLTRB(24, 48, 24, 16),
-      child: Align(
-        alignment: Alignment.topCenter,
-        child: Text(
+    final child = loading
+      ? CircularProgressIndicator()
+      : Text(
           textAlign: TextAlign.center,
           style: TextStyle(
             color: designVariables.labelSearchPrompt,
             fontSize: 17,
             height: 23 / 17,
           ).merge(weightVariableTextStyle(context, wght: 500)),
-          message)));
+          message!);
+
+    return Padding(
+      padding: EdgeInsets.fromLTRB(24, 48, 24, 16),
+      child: Align(
+        alignment: Alignment.topCenter,
+        child: child));
   }
 }

@sm-sayedi
Copy link
Collaborator Author

Thanks for the review. New changes pushed.

@sm-sayedi sm-sayedi requested a review from chrisbobbe August 8, 2025 02:01
@chrisbobbe
Copy link
Collaborator

Thanks, LGTM! Marking for Greg's review.

@chrisbobbe chrisbobbe requested a review from gnprice August 8, 2025 20:48
@chrisbobbe chrisbobbe assigned gnprice and unassigned chrisbobbe Aug 8, 2025
@chrisbobbe chrisbobbe added integration review Added by maintainers when PR may be ready for integration and removed maintainer review PR ready for review by Zulip maintainers labels Aug 8, 2025
Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks to you both for building this!

Comments below. A few are more for @chrisbobbe, and others for @sm-sayedi 🙂


final String text;
///
/// Figma; title and text:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this and just above say "text" for what the code calls "subtitle".

child: BottomSheetHeaderPlainText(text: zulipLocalizations.seeWhoReactedSheetNoReactions),
child: BottomSheetHeader(subtitle: zulipLocalizations.seeWhoReactedSheetNoReactions),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels odd to me to have something called a "subtitle" when there's no title.

Maybe "note"? "message"?

I guess "message" might sound like it's referring to a Zulip message; but we already use that name for dialogs and it seems fine. In any case "note" doesn't have that issue.

Comment on lines 162 to 168
final baseSubtitleStyle = TextStyle(
color: designVariables.labelTime,
fontSize: 17,
height: 22 / 17);

final effectiveSubtitle = switch ((buildSubtitle, subtitle)) {
(WidgetBuilderFromTextStyle build, null) => build(baseSubtitleStyle),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, interesting idea passing the desired style as an argument to the builder callback.

Comment on lines 168 to 170
(WidgetBuilderFromTextStyle build, null) => build(baseSubtitleStyle),
(null, String data) => Text(style: baseSubtitleStyle, data),
_ => null,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can make less verbose by leaving out the types

Suggested change
(WidgetBuilderFromTextStyle build, null) => build(baseSubtitleStyle),
(null, String data) => Text(style: baseSubtitleStyle, data),
_ => null,
(final build?, null) => build(baseSubtitleStyle),
(null, final data?) => Text(style: baseSubtitleStyle, data),
_ => null,

Comment on lines 173 to 174
// (should have been caught by `assert` in constructor)
if (effectiveTitle == null && effectiveSubtitle == null) throw ArgumentError();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I don't like throwing from a build method, in release mode, if we can avoid it. Certainly not going out of our way to detect a reason to throw.

What happens if this case somehow occurs and we don't have this line? The Column will have no children — so probably it's just empty, right? That's a lot better than crashing.

OTOH it'd be fine to have an assert here. That'd affect debug mode only, the same way as the assert in the constructor.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we remove the assert here, as it will already be caught in the constructor anyway?

});
}

/// The read receipts sheet.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
/// The read receipts sheet.
/// The read-receipts sheet.


final int messageId;

static const _helpCenterReference = '/help/read-receipts';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: "reference" is a bit vague about what sort of thing this is.

I think "relative URL" is a good term. The official term in the spec would be "relative-URL string":
https://url.spec.whatwg.org/#relative-url-string

and here the type says it's a string so we don't need to repeat that part. Therefore:

Suggested change
static const _helpCenterReference = '/help/read-receipts';
static const _helpCenterRelativeUrl = '/help/read-receipts';

(In the now-obsolete RFCs that previously defined the concept of a URL, this was a "URI-reference" and more specifically a "relative reference". Perhaps that's what you had in mind with this name.)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

separate nit: this private const can go on the one class that uses it, and then the reference to it doesn't need to repeat the class name.


// TODO(i18n): add locale-aware sorting
userIds = result.userIds.sortedByCompare(
(id) => storeNow.userDisplayName(id),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since store and storeNow are identical, this is equivalent:

Suggested change
(id) => storeNow.userDisplayName(id),
store.userDisplayName,

and keeps to our convention of always calling the store just store.

(This version also incorporates a nit that this can use a tearoff instead of a fresh closure.)

Comment on lines 159 to 161
}


// TODO: deduplicate the code with [ViewReactionsUserItem]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: double blank line

Comment on lines +162 to +163
// TODO: deduplicate the code with [ViewReactionsUserItem]
@visibleForTesting
class ReadReceiptsUserItem extends StatelessWidget {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment should get a twin at the other end, pointing back to this one.

That way when editing the other one, we might notice and look at this one to edit it the same way (or to go ahead and do this deduplication).

@chrisbobbe
Copy link
Collaborator

Ah, thanks Greg! @sm-sayedi would you mind taking care of Greg's feedback on the code I wrote? That seems simpler process-wise than if we both try to make our own edits to the branch, and I'd appreciate it :)

@sm-sayedi
Copy link
Collaborator Author

@chrisbobbe Sure, doing it now.

gnprice and others added 4 commits August 9, 2025 08:35
Fixes part of zulip#1285.

In particular this handles the case we need at the moment for zulip#667,
read receipts: a sentence with a single link in the middle of it.

Other cases noted in zulip#1285 are for a code font; or italics; or
inserting more string fragments before and after a tagged string
(for a Markdown link in quote-and-reply).  There's also zulip#1553
which calls for a sentence with two different links in it (to
use in an empty inbox).
…name

Before, this widget supported just one plain-styled multiline UI
string:
  https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3481-26993&m=dev

In addition to that feature, it now also supports:

- Richer text for that string, such as TextWithLink, which we'll use
  for read-receipts:
    https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=11367-20898&m=dev
- A "title" line above that string, which is bigger and bolder;
  we'll also use that for read-receipts.
- The header may appear with
  - a title and subtitle (as in the normal read-receipts case), or
  - just a title (as in read-receipts when loading or when nobody
    has read the message), or
  - just a message, as in the current caller in the view-reactions
    sheet
- (The title may be richly styled too; we can use this when showing
  the channel name in the channel action sheet header, zulip#1533, which
  calls for showing the channel privacy icon.)
Fixes: zulip#667

Co-authored-by: Greg Price <greg@zulip.com>
Co-authored-by: Chris Bobbe <cbobbe@zulip.com>
@sm-sayedi
Copy link
Collaborator Author

Thanks @gnprice for the review. New changes pushed, PTAL.

@sm-sayedi sm-sayedi requested a review from gnprice August 9, 2025 04:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
integration review Added by maintainers when PR may be ready for integration
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Show read receipts
4 participants